这篇文章先从阻塞与非阻塞,同步与异步之间的定义和关系说起,然后探讨liunx下的5种IO模型,支持非阻塞IO的select/poll/epoll系统调用的基本原理,然后通过Java代码搭建bio方式的服务端,改进服务器在并发场景下bio多线程和线程池的实现方式,最后介绍Java nio来实现一个服务器和多个客户端对话。
阻塞与非阻塞,同步与异步
如果从程序调用来讲,阻塞是指我们执行一个函数调用不能立即得到返回值,而依赖于其他外部的事件完成,比如说网络包的到达、IO操作的完成,否则的话就是非阻塞。其根本原因是cpu执行指令的速度远大于网络操作、IO操作的速度,这种速度差导致了程序运行的阻塞。
同步与异步是从另一个角度来看程序的运行,它是指在程序运行的过程中遇到阻塞事件是否能够让出cpu去处理其他的事情,如果能的话,就是异步的,如果不能的话就是同步的。
从这个角度来看,阻塞与非阻塞,同步与异步是一个事情的两个方面,以IO为对象来说,阻塞IO必然导致着同步,而非阻塞IO也必然导致着异步。
liunx的5种网络IO模型
上图显示了liunx的5种IO模型,其中包括了读的两个执行过程:阶段1)从IO设备读取数据到内核空间(wait for data)和 阶段2)从内核空间复制数据到用户空间(copy data from kernel to user)
- 阻塞IO,不论是阶段1还是阶段2都是处于阻塞状态
- 非阻塞IO,不断的检查数据是否准备好,如果没有直接返回一个错误码,第二阶段也是阻塞的
- IO多路复用,可以看到IO多路复用阶段1和阶段2都是阻塞的,但是不同的是,IO多路复用在阶段1可以同时处理多个文件描述符,这使得在原本用多个线程完成的任务可以在一个线程中完成,但从流程上看,IO多路复用还是阻塞的IO,并且是同步IO。
- 信号量驱动的IO使用信号来通知数据是否准备好,当数据准备好了才通知线程继续处理,所以阶段1是非阻塞的,但是阶段2仍然是阻塞的过程。
- 异步IO,异步IO不论是阶段1还是阶段2都是非阻塞的,调用异步IO接口后,函数会立即返回,当数据成功拷贝到用户空间后再通知用户程序继续处理。
从上图看来,前四种IO模型在第2阶段的处理都是一样的,而在第1阶段的处理方式不同。
Java nio的实现是基于IO多路复用实现的,它实现了在同一线程下处理多个文件描述符的功能,这种功能使得应用程序避免了因为线程切换而导致的性能损失。IO多路复用的实现需要系统调用的支持,例如select/poll/epoll,因为本文重点不在这里,所以不详述,可以参考文末的参考文献。
基础BIO Server
从这里开始,我们开始用Java API来写一个server和client通信,以此来探索Java对不同io方式的支持。
首先是最基础的bio,使用ServerSocket API构建一个简单的服务器,基本的过程如下
- 初始化ServerSocket
- 绑定ServerSocket到一个InetSocketAddress(ip,port)
- 调用accept()方法获取客户端socket,这个方法是阻塞的
- 通过client socket获取对应的inputStream和outputStream读取客户端的信息,并通过outputStream写回信息
主要的代码实现如下
在实际测试的过程中,有一个有趣的问题,如何判断客户端已经断开的连接,不管是正常的,还是不正常的服务端都要正确的处理,在上面的代码我们做的两种处理,一是设置client socket的setSoTimeout(time),这个api的作用是让client的read操作阻塞指定的时间,如果超过指定的时间就抛出SocketException(继承自IOException),强制断开与客户端的连接,这是服务端主动断开连接。
二是在实际情况下,如果客户端断开了连接,服务端并不知道客户端已经断开了连接,并且会一直读到null,这时候服务端在每次读取信息之前就要检查客户端是否还处于连接的状态,我们用sendUrgentData()去判断客户端是否处于连接的状态,如果不是则会抛出IOException,这样,服务端就知道了客户端已经断开了连接,可以关闭连接,不然的话,服务器会一直处于忙等的状态,并且无法处理其他的连接请求。
另外需要注意的是每次用write发送信息的时候必须用\n标注一行信息的结束,并且用flush刷新才会发送过去。
我们在每个客户端的逻辑中连续给服务端发送了两条信息,并且开启了10个线程的客户端,每个客户端主要逻辑如下:
基础bio的全部代码可以点击这里
基于线程池的BIO Server
上面的Server可以顺利的处理10个客户端的连接,这得益于我们对服务器和客户端不同状态的正确处理,单从整个过程来看,我们必须串行的逐个处理每个请求,这在并行大行其道的今天,没有让我们用到多处理器并发的优势,在这一节,我们将接受客户端连接放在主线程里,把对每个client socket的读写任务放到每个子线程中去运行,实现这个想法有两种做法:一种是为每个连接开启一个线程,另一种是使用线程池,在少量连接情况的第一种做法是是比较合理的,但是如果在短时间有大量连接的情况下,第二种方案合理,我们使用线程池的方式实现。
在Java中,我们可以通过如下API开启一个固定数目的线程池
其他的处理逻辑不变,主要逻辑代码如下
基础的NIO Server-Client交互过程
Java的BIO是面向缓冲区和通道的,面向缓冲区的意思是说数据是要写到缓冲区中,然后将缓冲区的数据通过通道出去, 其中最常用的缓冲区是ByteBuffer,它有四个重要属性:
- capacity,缓冲区的容量
- position,最后一个元素的下一个位置
- mark,当position需要变化时存储position
- limit,最大能读入或写出的位置
在写入缓冲区后,读取缓冲区需要调用flip()方法,这个方法的作用是将limit=position,将position置为0,然后才可以成功读取。
Channel分为4种:
- FileChannel从文件读写数据
- DatagramChannel以UDP的形式从网络中读写数据
- SocketChannel以TCP的形式从网络中读写数据
- ServerSocketChannel可以开启一个非阻塞的ServerSocket
我在这里写了一个简单的例子使用ServerSocketChannel和SocketChannel使用ByteBuffer进行一次对话的例子其主要过程如下:
Server端:
- 使用ServerSocketChannel.open()开启一个server
- 使用bind方法绑定server到指定的InetSocketAddress
- 使用accept()方法获取客户端的SocketChannel
- 使用allocate()方法初始化一个ByteBuffer
- 使用client SocketChannel读取数据写入到ByteBuffer
- byteBuffer flip()之后,读取数据并打印输出
Java代码如下
Client端:
- 使用SocketChannel.open()开启一个client
- 使用connect方法连接对应的InetSocketAddress
- 使用allocate()方法初始化一个ByteBuffer
- 写入数据后flip(),然后用write方法写入数据
Client端代码如下
全部代码可以点击这里
值得注意的是,在每次需要从对buffer填充后,需要调用flip()才可以读取buffer中的数据。
单Selector的NIO Server实现
在上面的例子,我们仅仅使用到Socket Channel演示了进行远程通信的一个过程,当然Socket Channel的非阻塞性质并没有显示出来,接下来,我们使用非阻塞Socket来实现一个Client-Server通信。
不论是使用ServerSocketChannel还是SocketChannel都可以通过configureBlocking
方法设置为非阻塞Channel,这个方法的意义不仅仅是在执行阻塞方法的时候直接返回,如果我们深入查看,这个方法是来自于ServerSocketChannel和SocketChannel的共同父类SelectableChannel,这个方法更深层次的含义是非阻塞IO和选择性是紧密相关的,实验证明Java必须将Channel设置为非阻塞的才能进行选择。
最后,另外一个重要的类Selector,这个类是实现Java IO多路复用的重要类,它的功能在于两方面:一是非阻塞的Channel可以使用register
方法注册感兴趣的阻塞事件,另一方面它可以通过select
方法选出已经准备就绪的阻塞事件做进一步的处理。这在多用户连接client-server模式下这种处理方式是很有用的,一方面,每个连接的client连接成功后都可以注册读事件,另一方面服务器可以挑选已经就绪的client进行操作,而不必要仅仅等一个client的读,相比阻塞的Socket处理模式这大大提高了cpu的利用率。
还有一个重要的类SelectionKey,选择好就绪的事件后,selector.selectedKeys()
会返回一个就绪的事件集合,根据获取到key,我们可以选择不同的处理方法做进一步的处理。另一个值得注意的问题是在处理完一个key之后注意在key集合中除去它,防止多次处理。单个用户多次发送接受的情况处理用client.read()读取的长度进行判断,如果等于-1,我们就认为客户端断开了。
关于整体的工作示意图如下
从整个图中可以看到Selector在整个过程中具有举足轻重的作用,如果从Reactor模式的角度来看,Selector的select()方法实现了Event Demultiplexer角色的就绪事件的选取,并且和处理注册兴趣事件的Handle角色以及处理事件分发的Dispatcher角色右直接的关联。
以下是我们实现的Java NIO的服务器实例
我们在客户端开启了10个线程模拟10个用户连接,并且每个用户给服务端发送两条不同的信息,以此来测试服务器,全部的代码可以点击这里。
在实现的过程中遇到几个问题,解决后总结如下
- 当从selector.selectedKeys()中取出一个key处理后,必须将其从集合中删除,不然会重复处理
- read,write的key删除后在下一轮还是存活,探究许久这个不知所以然,这使得我们可以变更key的interestOps实现多轮读写,但是我们必须在读中判断师傅可读现在,现在的解决方法是通过read的返回值为-1判定客户端断开(-1 if the channel has reached end-of-stream)。
将读写任务分离出去的单Selector服务器实现
虽然我们使用Selector实现了在单线程处理多个请求的功能,但从整个过程来仍然串行的,接受连接,读写数据,每次我们只能执行一项任务,终归到底我们是一个线程,如果读写数据的过程比较长,那么整个服务器就被阻塞在读写任务那里,不能再接受新连接请求,这是我们不愿意看到的。另一方面,多处理器的优势我们没有用到,想到这里,我们很自然的想到将读写任务从主线程解放出来,通过子线程完成读写任务,主线程只关心出来连接,分发读写任务,所以,我们的整个过程变成了下面这样。
因为将读写任务交给了子线程,主线程就可以继续轮询处理其他时间,但是有个问题,正在处理的读写事件并没有从SelectionKeys里面剔除除去,下一次轮询仍然有这个读事件,只不过读出的是空而已。一个解决方法是使用cancel取消key,在完成了写任务后再重新注册key,另一个方法是建立一个在读队列,每次处理key之前看是否在队列中,如果在的话不处理,在完成读写任务后,子线程将现在的key从队列中移除,我采用了第二种方案。
在主线程上,我们做了一下改变
- 加入了正在读写队列,在2处进行了逻辑判断
- 在3处读操作交给了子线程池处理Worker
|
|
Worker的主要逻辑如下
全部代码请看这里
使用多Selector进一步解放主线程任务
将读写任务分离出去的单Selector服务器实现虽然实现了将读写任务交给了子线程,但是主线程还是要识别并分发read,write时间,那么我们进一步划分任务,让主线程的Selector仅处理accept时间,而将接受到的Client Socket的读写时间交给另一个Selector去处理,岂不更好,说做就做,进一步改进。我们的结构图改成了这样
主要代码如下
子Selector的处理代码
在实现的过程中,我们使用遇到了一个问题,就是在client.register(selector, SelectionKey.OP_READ);
注册Client到子Selector中时出现了死锁,经过多次查找资料,在一个论坛里找到了答案(点击这里,感谢!),原来register方法在select方法阻塞的过程中是也是阻塞的,尽管我们使用wakeup方法使得select方法立即返回,但是也不保证在下一次select方法调用之前执行了register方法,所以最好使用有有效期限的select(time)方法,这样就可以使得正确执行。看来以后遇到问题要先认真看官方文档了。
关于源码
你可以在这里找到本文的所以源码,你同时可以在我的博客看到这篇文章,欢迎批评指正。
参考文档
Java I/O模型从BIO到NIO和Reactor模式
Java并发编程之NIO简明教程
Linux下的五种IO模型
IO多路复用之select、poll、epoll详解
Reactor模式详解